图形引擎实战:Unity Shader变体管理流程

元数据

作者: 搜狐畅游引擎部​
标题: 图形引擎实战:Unity Shader变体管理流程
分类: #unity #unity/cg
地址: https://mp.weixin.qq.com/s/1u67OWs-TNkMa_NCDK-iBg

笔记

作者主页 跳转

https://www.zhihu.com/org/sou-hu-chang-you-yin-qing-bu

什么是Shader变体 跳转

例如在代码中#pragma multi_compile SHADOW_ON SHADOW_OFF,对逻辑上有差异的地方用#ifdef SHADOW_ON或#if defined(SHADOW_ON)区分,#if defined()的好处是可以有多个条件,用与、或逻辑运算连接起来

个人角度对Shader变体管理个人是指TA、引擎、图程以及其他Shader开发者,在编写Shader时就要注意变体的问题 跳转

首先,该用if用if,之前虽然说在GPU执行分支开销不低,但只是相对而言的,如果你的if else执行的是整个光照计算,那显然是不可接受的,但假如if else加起来没两行代码,那显然是无所谓的,要是在变体极多的时候去掉个Keyword,变体数直接砍半,对项目的好处是极大的,这需要开发者自己权衡。其次,之前的例子都用的是multi_compile,但实际上不一定需要multi_compile,某些情况下用shader_feature是可以的

multi_compile和shader_feature的区别用multi_compile声明的Keyword是全排列组合 跳转

#pragma multi_compile A B
#pragma multi_compile C D E

组合出来就是AC、AD、AE、BC、BD和BE6个,如果再来一个#pragma multi_compile F G显然会直接翻倍为12个

假如将上述multi_compile替换为shader_feature 跳转

#pragma shader_feature A B
#pragma shader_feature C D E

我打包只打一个材质,这个材质用到了变体组合AC,那么打包时只会将AC打出来。
如果我的材质引用的是AE,那么会打出AC和AE,因为C是第二个Keyword声明组的默认Keyword,当你的材质用了这个Shader,却没有发现没有引用这一声明组的任何一个Keyword(比如上面CDE都没引用),就会退化成第一个默认Keyword(上面的例子是C)。
所以一般声明Keyword组如果包含默认Keyword、关闭Keyword不会声明XXX_OFF,而是声明成 #pragma multi_compile _ C D,这样如果材质引用AD,则会打出A和AD,不会减少变体数量,但可以减少Global Keyword的数量(Unity 2020及以下版本只能有384个Global Keyword,2021之上有42亿个。)
详见Shader Keywords:https://docs.unity3d.com/2019.4/Documentation/Manual/shader-keywords.html

打包规则打包时会将multi_compile和shader_feature分为两堆,分别计算组合数,然后两者再组合 跳转

#pragma multi_compile A B
#pragma multi_compile C D
#pragma shader_feature E F
#pragma shader_feature G H

当你只打两个材质,引用的变体分别是ADEG和ACFH,前两个multi_compile组直接组合成4个变体,后面两个shader_feature组分别引用到了EG和FH,然后两组组合4*2,最后打出8个变体

multi_compile建议用于声明可能实时切换的全局Keyword声明组 跳转

例如阴影、全局雾效、雨、雪。因为一个物体可能在多个场景使用,材质也就会在多个场景用到,一个场景有雾,另一个场景有雨,而材质只能引用一组Keyword组合,为了能实时切换,就需要把切换效果后的变体也打入包中;而对于材质静态的Keyword声明组就可以用shader_feature,例如这个材质是否用到了NormalMap,是否有视差计算,这个在打包时就确定好的,运行时不会动态改变,即可声明为shader_feature。multi_compile_local适合解决打包时不确定变体,需要在运行时动态切换单个材质变体的需求,例如某些建筑、角色需要运行时溶解;溶解只针对当前角色的材质而不是全局的,需要Material.EnableKeyword,所以用Local;并且需要溶解/未溶解的变体都被打入包中,所以需要声明为multi_compile在打包时排列组合,组合起来就是multi_compile_local

变体剔除Unity提供了IPreprocessShaders接口,让用户自定义剔除条件 跳转

class StripInstancingOnKeyword : IPreprocessShaders
{
public void OnProcessShader(Shader shader, ShaderSnippetData snippet, IList<ShaderCompilerData> inputData)
{
for (int i = inputData.Count - 1; i >= 0; i--)
{
ShaderCompilerData input = inputData[i];
//Global And Local Keyword
if (input.shaderKeywordSet.IsEnabled(new ShaderKeyword("INSTANCING_ON")) || input.shaderKeywordSet.IsEnabled(new ShaderKeyword(shader, "INSTANCING_ON")))
{
inputData.RemoveAt(i);
}
}
}
}

一般情况下,项目会编写一个配置文件,里面记录各种需要剔除的变体条件 跳转

比如URP项目不需要BuildIn下的ForwardBasePass、DeferredPass,可以直接将这些Pass剔除掉,防止项目中有BuildIn下残留的变体。有些Shader抄案例时,附带了#pragma multi_compile_fog等Unity自动生成的关键字,而实际上Shader可能用不到,可以通过项目整体剔除来抵消项目人员犯错。还可以根据项目需求编写条件,比如说项目中角色Shader带有高配和低配关键字,用于区分着色计算,高配用于展示,低配用于战斗,能确定战斗效果(例如溶解、石化等)变体不可能出现高配变体上,因此可以判断当同时出现高模Keyword和战斗效果Keyword时剔除变体。在我们项目中,通过变体剔除,能将占用上GB内存的ShaderLab降低到20多MB,可见变体剔除的必要性。对于变体剔除工具的设计,可以参考我的个人变体剔除工具:https://github.com/crossous/SocoTools/tree/main/SocoShaderVariantsStripper

变体预热的方法Unity提供了ShaderVariantCollection.WarmUp、Shader.WarmupAllShaders这些接口 跳转

跑变体收集这个是相对自动的方法 跳转

使用方法是在ProjectSetting>Graphics的最下面,先Clear掉当前的记录,然后进行游戏,尽量覆盖大多数游戏内容,之后点击Save to asset保存

变体收集文件的增删查改既然Unity内置的工具不好用,那就要想办法自定义工具 跳转

private ShaderVariantCollection mCollection;
private Dictionary<Shader, List<SerializableShaderVariant>> mMapper = new Dictionary<Shader, List<SerializableShaderVariant>>();
//将SerializedProperty转化为ShaderVariant
private ShaderVariantCollection.ShaderVariant PropToVariantObject(Shader shader, SerializedProperty variantInfo)
{
PassType passType = (PassType)variantInfo.FindPropertyRelative("passType").intValue;
string keywords = variantInfo.FindPropertyRelative("keywords").stringValue;
string[] keywordSet = keywords.Split(' ');
keywordSet = (keywordSet.Length == 1 && keywordSet[0] == "") ? new string[0] : keywordSet;
ShaderVariantCollection.ShaderVariant newVariant = new ShaderVariantCollection.ShaderVariant()
{
shader = shader,
keywords = keywordSet,
passType = passType
};
return newVariant;
}
//将ShaderVariantCollection转化为Dictionary用来访问
private void ReadFromFile()
{
mMapper.Clear();
SerializedObject serializedObject = new UnityEditor.SerializedObject(mCollection);
//serializedObject.Update();
SerializedProperty m_Shaders = serializedObject.FindProperty("m_Shaders");
for (int i = 0; i < m_Shaders.arraySize; ++i)
{
SerializedProperty pair = m_Shaders.GetArrayElementAtIndex(i);
SerializedProperty first = pair.FindPropertyRelative("first");
SerializedProperty second = pair.FindPropertyRelative("second");//ShaderInfo
Shader shader = first.objectReferenceValue as Shader;
if (shader == null)
continue;
mMapper[shader] = new List<SerializableShaderVariant>();
SerializedProperty variants = second.FindPropertyRelative("variants");
for (var vi = 0; vi < variants.arraySize; ++vi)
{
SerializedProperty variantInfo = variants.GetArrayElementAtIndex(vi);
ShaderVariantCollection.ShaderVariant variant = PropToVariantObject(shader, variantInfo);
mMapper[shader].Add(new SerializableShaderVariant(variant));
}
}
}